WKWebView 小记

WKWebView 早在 iOS 8 中已经推出,至今已经过去很久了,但是由于项目时间过久,代码比较老,所以一直停留在 UIWebView,没有尝试用过 WK。这次有个机会改造,在这里记录一下遇到的一些小问题。

在使用 UIWebView 时,我们从来不需要考虑 Cookie 的问题。但是 WKWebView 不会将 Cookie 存入到标准的 Cookie 容器 NSHTTPCookieStorage 中,也不会自动读取,所以使用时需要我们手动去管理 Cookie。而由于情况繁多,WKWebView 的 Cookie 问题变成很多人替换 UIWebView 的一个犹豫的原因。

目前我的解决方案为:

  • WKWebView loadRequest 前,在 request header 中设置 Cookie;
1
2
3
4
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:self.urlString ? : @""]];
// 在 request header 中设置 Cookie, 解决首个请求 Cookie 带不上的问题
[request addValue:[LCSCookieManager allCookieString] forHTTPHeaderField:@"Cookie"];
[self.webView loadRequest:request];
1
2
3
4
5
6
7
8
9
10
11
12
13
14
+ (NSString *)allCookieString {
NSMutableString *cookieString = [[NSMutableString alloc] init];
// 取出cookie
NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
for (NSHTTPCookie *cookie in cookieStorage.cookies) {
NSString *excuteJSString = [NSString stringWithFormat:@"%@=%@;", cookie.name, cookie.value];
[cookieString appendString:excuteJSString];
}
// 删除最后一个“;”
if (cookieString.length > 0) {
[cookieString deleteCharactersInRange:NSMakeRange(cookieString.length - 1, 1)];
}
return cookieString;
}
  • 通过 document.cookie 设置 Cookie 解决后续页面(同域)Ajax、iframe 请求的 Cookie 问题;
1
2
3
4
5
6
7
8
9
- (void)setupDocumentCookie {
NSArray *cookieArray = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies];
NSMutableString *cookies = [NSMutableString string];
for (NSHTTPCookie *cookie in cookieArray) {
[cookies appendFormat:@"document.cookie = '%@';", [LCSCookieManager javascriptStringWithCookie:cookie]];
}
WKUserScript *cookieScript = [[WKUserScript alloc] initWithSource:cookies injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:NO];
[self.webView.configuration.userContentController addUserScript:cookieScript];
}
1
2
3
4
5
6
7
8
9
10
11
12
+ (NSString *)javascriptStringWithCookie:(NSHTTPCookie*)cookie {
NSString *string = [NSString stringWithFormat:@"%@=%@;domain=%@;path=%@;",
cookie.name,
cookie.value,
cookie.domain,
cookie.path ?: @"/"];

if (cookie.secure) {
string = [string stringByAppendingString:@"secure=true"];
}
return string;
}

在阅读别人的博客时,观察到 document.cookie 的设置没有处理 Cookie 的 domain 和 path,我在测试时发现这样 Cookie 无法设置成功,所以这里添加了 Cookie 所有的信息。

  • 即便如此,防止有些情况仍然请求中无法携带 Cookie,在每次 Web 开始加载时判断,并且在需要时添加 Cookie;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
NSURL *URL = navigationAction.request.URL;
NSString *scheme = [URL scheme];

NSDictionary *headerFields = navigationAction.request.allHTTPHeaderFields;
NSString *cookie = headerFields[@"Cookie"];
if (!cookie) {
NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:URL];
request.allHTTPHeaderFields = headerFields;
[request addValue:[LCSCookieManager allCookieString] forHTTPHeaderField:@"Cookie"];
[self.webView loadRequest:request];
decisionHandler(WKNavigationActionPolicyCancel);
return ;
}
decisionHandler(WKNavigationActionPolicyAllow);
}

上述处理之后,目前线上版本关于 Cookie 的问题暂时没有发现。

关于请求拦截

客户端与 H5 交互的常用手段之一,便是拦截请求,根据 scheme 进行区分和不同的处理。这也是目前项目中运用较多的手段。WKWebView 的拦截处理方式与 UIWebView 并没有什么不同,只是用另外的的代理函数处理。

1
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler;

注意这个代理函数必须手动调用 decisionHandler 这个 block 来说明是否允许此次跳转。

关于 openURL

测试时发现,在 WKWebView 中,URL Scheme and App Store links 不会自动跳转,因此需要拦截并且手动处理此类跳转。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler
{
NSURL *url = navigationAction.request.URL;
NSString *urlString = (url) ? url.absoluteString : @"";

// iTunes: App Store link
if ([urlString isMatch:RX(@"\\/\\/itunes\\.apple\\.com\\/")]) {
[[UIApplication sharedApplication] openURL:url];
decisionHandler(WKNavigationActionPolicyCancel);
return;
}
// Protocol/URL-Scheme without http(s)
else if (![urlString isMatch:[@"^https?:\\/\\/." toRxIgnoreCase:YES]]) {
[[UIApplication sharedApplication] openURL:url];
decisionHandler(WKNavigationActionPolicyCancel);
return;
}
decisionHandler(WKNavigationActionPolicyAllow);
}

关于 WKScriptMessageHandler 协议的循环引用

在 WKWebView 中,使用 WKUserContentController 便可实现与 H5 的交互。只是这个方式会导致循环引用,使 VC 无法 dealloc。

1
- (void)addScriptMessageHandler:(id <WKScriptMessageHandler>)scriptMessageHandler name:(NSString *)name

在这里我们新增一个中间处理层来处理这种情况。

新建 WeakScriptMessageHandler 实现协议 WKScriptMessageHandler,并且暴露对外的一个代理,此代理同样需要实现 WKScriptMessageHandler 协议;

1
2
3
4
5
6
7
@interface LCSWeakScriptMessageHandler : NSObject <WKScriptMessageHandler>

@property (nonatomic, weak) id<WKScriptMessageHandler> scriptDelegate;

- (instancetype)initWithDelegate:(id<WKScriptMessageHandler>)scriptDelegate;

@end

在 .m 中,代理方法调用时,再调用真正的代理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@implementation LCSWeakScriptMessageHandler

- (instancetype)initWithDelegate:(id<WKScriptMessageHandler>)scriptDelegate {
self = [super init];
if (self) {
_scriptDelegate = scriptDelegate;
}
return self;
}

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
if (self.scriptDelegate) {
[self.scriptDelegate userContentController:userContentController didReceiveScriptMessage:message];
}
}

@end

webView 使用时,只需要将 Handler 设置好并且正常实现 WKScriptMessageHandler 即可。

1
2
3
4
5
6
7
- (WKUserContentController *)userContentController {
if (!_userContentController) {
_userContentController = [[WKUserContentController alloc] init];
[_userContentController addScriptMessageHandler:[[LCSWeakScriptMessageHandler alloc] initWithDelegate:self] name:@"bridge_callByJS"];
}
return _userContentController;
}

注意在 dealloc 时需要调用相应的 - (void)removeScriptMessageHandlerForName:(NSString )name; 方法。*

关于 webView.estimatedProgress

在 WKWebView 中,新增了一个属性 webView.estimatedProgress(0~1) 使我们可以通过它来设置一个 webView 的加载进度条,但是这只是一个估算的进度,并不十分准确,实践发现会出现最终值不为 1 的情况。

如果只是导航栏下方的自定义进度条并无大碍,最多可以适时手动隐藏,但是如果是想添加占位图就不是那么合适。为了较为准确的掌握网页的加载进度,我尝试了使用 document.readyState 这个状态来控制,但是实际运用时发现并不理想。

1
2
3
4
5
6
7
8
9
10
11
12
[webView evaluateJavaScript:@"document.readyState" completionHandler:^(id _Nullable result, NSError * _Nullable error) {
if ([result isEqualToString:@"complete"]) {
[self stopLoading];
if (!self.placeholderImageView.hidden) {
[UIView animateWithDuration:0.2 animations:^{
self.placeholderImageView.alpha = 0;
} completion:^(BOOL finished) {
self.placeholderImageView.hidden = YES;
}];
}
}
}];

最终,我还是通过了一种非常质朴的方式🌚,拦截与 H5 约定好的一个type,在上述拦截代理方法里进行处理,这样虽然达不到占位图消失与内容显示完美吻合,但目前使用起来没有太大问题。

关于 webView.scrollView 的代理

由于 WKWebView 的滚动速率较慢,使用时需要需要通过 scrollView.delegate 调整滚动速率:

1
2
3
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
scrollView.decelerationRate = UIScrollViewDecelerationRateNormal;
}

然而这样设置 scrollView.delegate 为自身后,在 iOS 9 系统版本的机器上,会在 dealloc 方法中创建弱引用对象,从而导致崩溃。

所以在使用时,需要在 dealloc 方法里设置好:

1
_webView.scrollView.delegate = nil;

关于白屏问题

WKWebView 占用内存过大时,不会导致系统 crash,但是WebContent Process 会 crash,从而出现白屏现象,详情原因可以看 腾讯Bugly 的这篇博客

我在测试时没有发现这种情况,但是为了安全起见,仍然采取了相应的措施来处理这种情况。

  • 在 WKWebView 总体内存占用过大,页面即将白屏的时候,系统会调用代理中的这个函数
1
- (void)webViewWebContentProcessDidTerminate:(WKWebView *)webView API_AVAILABLE(macosx(10.11), ios(9.0));

在这里可以通过 reload webView 来防止白屏出现;

  • 在WKWebView白屏的时候,另一种现象是 webView.titile 会被置空, 因此,可以在 viewWillAppear 的时候检测 webView.title 是否为空来 reload 页面。

总结

WKWebView 相比 UIWebView 而言可能改变较多,有一些坑,需要我们去多了解之后再去应用。但其在内存消耗以及稳定性方面有很大优势,对其应采取积极的态度,有机会可以替换体会一番。

参考链接:

WKWebView 那些坑

WKWebView从入门到趟坑